Part 3. 시간 및 상태
58개 중 3개. 동시 또는 거의 동시 수행을 지원하는 병렬 시스템, 하나 이상의 프로세스가 동작하는 환경에서 시간과 상태를 부적절하게 관리하여 발생한다. 관통 질문: 내가 검사한 그것을, 사용하는 순간에도 여전히 같은 것이라고 보장할 수 있는가? 가이드 원문 순서 그대로 진행(교육 순번 = 원문 번호).
1. 경쟁 조건: 검사시점과 사용시점(TOCTOU) CWE-367
신뢰 경계:
access()로 검사한 파일 이름 → 그 사이 변경 가능 →fopen()이 실제로 여는 대상 ⚠️ 이름으로 검사하고 이름으로 열지 말라. 검사한 그 핸들을 끝까지 써라.
공격 시나리오: 검사(time of check)와 사용(time of use) 사이의 좁은 시간차를 노려 자원을 바꿔치기하면, 프로그램은 검사에 통과한 것과 다른 대상을 신뢰한 채 다뤄 권한 상승·파일 덮어쓰기로 이어진다.
❌ Bad
void file_operation(char* file)
{
if (!access(file, W_OK)) /* 검사 시점(TOC) */
{
f = fopen(file, "w+"); /* 사용 시점(TOU) — 그 사이 바꿔치기 가능 */
operate(f);
}
}✅ Good
void file_operation(char* file_name)
{
int fd = open(file_name, O_WRONLY|O_CREAT|O_EXCL, S_IRWXU); /* 생성·검사 원자적 */
if (fd == -1) { /* 오류 처리 */ }
if (fchmod(fd, S_IRUSR) == -1) { /* 오류 처리 */ } /* chmod 대신 fchmod */
close(fd);
}치환 규칙: access()+fopen() 이름 기반 처리 금지 → 파일 디스크립터 기반으로 열고 그 핸들을 계속 사용, open(O_CREAT|O_EXCL) 로 생성·검사를 원자화, chmod 대신 fchmod.
보충 — TOCTOU는 실제로 어떻게 털리는가
- ① 임시 파일 심볼릭 링크:
/tmp/app.1234가 없으면 만드는 코드 사이에 공격자가ln -s /etc/shadow /tmp/app.1234를 걸면fopen(...,"w")가 root 권한으로/etc/shadow를 덮어쓴다.tmpnam()/mktemp()는 이 결함 그 자체 —mkstemp()로 교체.- ② 검사하고 나서 실행하기: 서명을 검증한 뒤 그 사이 파일이 악성 바이너리로 교체되고, “검증했으니까” 그대로 실행된다. 백신의 “스캔 후 열기”, 업로드의 “검사 후 이동”도 같은 구조.
- ③ 업무 로직 경쟁: 멀티 워커가 동시에 잔액을 조회(10,000원 충분)하고 각자 차감하면 잔액이 음수가 된다.
SELECT확인 후UPDATE하지 말고, 조건을UPDATE문 안에 넣고 영향 행 수를 확인.- 실제 사례: CVE-2018-15664(docker cp 심볼릭 링크 경쟁, 성공률 1%라도 “수십 초 시도하면” 성공), CVE-2016-5195(Dirty COW, 커널 copy-on-write 경쟁으로 일반 사용자가 root 획득, 9년간 존재).
- 찾는 법:
access()/stat()뒤에open()/fopen()·tmpnam()/mktemp()· 경로 기반chmod/chown/stat·SELECT확인 후UPDATE.
2. 제대로 제어되지 않은 재귀 CWE-674
신뢰 경계: 외부 입력이 재귀 깊이를 결정 → 종료 조건·깊이 제한 없이 → 재귀 호출 ⚠️ 재귀에는 “언제 멈추는가(base case)“와 “얼마나 깊어질 수 있는가(깊이 제한)” 두 가지 통제가 모두 필요하다.
공격 시나리오: 공격자가 재귀 깊이를 키우는 입력을 주면 스택 오버플로우와 CPU·메모리 고갈로 프로세스가 강제 종료되어 서비스 거부(DoS)가 발생한다.
❌ Bad
int fac(n)
{
return n * fac(n-1); /* 종료 조건이 없어 무한 재귀 -> 스택 고갈 */
}✅ Good
int i = 0;
int fac(int n)
{
if (n <= 0) return 1; /* 귀납 조건(base case) */
if (i > 10000) exit(1); /* 깊이 한계 초과 시 종료 */
else i += 1;
return n * fac(n-1);
}치환 규칙: 모든 재귀 함수에 종료 조건 + 깊이 카운터 상한을 동시에 둔다.
3. 심볼릭명이 정확한 대상에 매핑되어 있지 않음 CWE-386
신뢰 경계: 파일 이름/심볼릭 링크 → 시간에 따라 대상이 바뀔 수 있음 →
fopen()이 실제로 여는 대상 ⚠️ 파일 “이름”은 공격자가 언제든 다른 실체로 갈아끼울 수 있는 포인터라고 생각하라.
공격 시나리오: access() 로 검사한 뒤 fopen() 으로 여는 사이에 파일을 심볼릭 링크로 바꿔치기하면 /etc/passwd 같은 민감 파일을 열거나 덮어쓰게 된다.
❌ Bad
if (!access(file, W_OK))
{
f = fopen(file, "w+"); /* /etc/passwd 링크 가능 */
operate(f);
}✅ Good
char* filename;
if (mkstemp(filename)) /* 랜덤 임시 파일 이름 — 바꿔치기 예측 불가 */
{
FILE* tmp = fopen(filename, "wb+");
while ((recv(sock, recvbuf, DATA_SIZE, 0) > 0) && amt)
amt = fwrite(recvbuf, 1, DATA_SIZE, tmp);
}치환 규칙: 고정 이름 임시 파일 대신 mkstemp(), 디렉터리 재귀 탐색 시 심볼릭 링크를 따라가지 않도록 검사, 검사와 사용을 파일 디스크립터 단위로 묶어 이름 재해석 자체를 없앤다.
Part 3 미니 체크리스트
-
access()/stat()뒤에open()/fopen()이 이어지는 곳이 있는가(이름 재사용) -
tmpnam()/mktemp()/tempnam()이 남아 있는가 →mkstemp()로 교체했는가 - 재귀 함수마다 종료 조건과 깊이 상한이 모두 있는가
-
SELECT로 확인 후UPDATE하는 패턴이 있는가 → 트랜잭션/조건부 갱신으로 대체했는가